Add AJAX category management system. Includes suggestion system, dialogs for setting...
authorAndrew Garrett <werdna@users.mediawiki.org>
Tue, 8 Sep 2009 15:02:41 +0000 (15:02 +0000)
committerAndrew Garrett <werdna@users.mediawiki.org>
Tue, 8 Sep 2009 15:02:41 +0000 (15:02 +0000)
12 files changed:
includes/AutoLoader.php
includes/DefaultSettings.php
includes/OutputPage.php
includes/Skin.php
includes/Xml.php
js2/ajaxcategories.js [new file with mode: 0644]
js2/mwEmbed/jquery/plugins/jquery.suggestions.js [new file with mode: 0644]
js2/mwEmbed/mv_embed.js
languages/messages/MessagesEn.php
skins/common/images/add.png [new file with mode: 0755]
skins/common/images/remove.png [new file with mode: 0755]
skins/common/shared.css

index 681eb95..cae8839 100644 (file)
@@ -619,6 +619,7 @@ $wgJSAutoloadLocalClasses = array(
        // phase 2 javascript:
        'uploadPage' => 'js2/uploadPage.js',
        'editPage' => 'js2/editPage.js',
+       'ajaxCategories' => 'js2/ajaxcategories.js',
 );
 
 //Include the js2 autoLoadClasses
index 698ec27..0ef9de4 100644 (file)
@@ -4195,3 +4195,8 @@ $wgCrossSiteAJAXdomainExceptions = array();
  * The minimum amount of memory that MediaWiki "needs"; MediaWiki will try to raise PHP's memory limit if it's below this amount.
  */
 $wgMemoryLimit = "50M";
+
+/**
+ * Whether or not to use the AJAX categories system.
+ */
+$wgUseAJAXCategories = false;
index 3adb63b..cbbd3a8 100644 (file)
@@ -1115,6 +1115,11 @@ class OutputPage {
                if( $wgUser->getBoolOption( 'editsectiononrightclick' ) ) {
                        $this->addScriptFile( 'rightclickedit.js' );
                }
+               
+               global $wgUseAJAXCategories;
+               if ($wgUseAJAXCategories) {
+                       $this->addScriptClass( 'ajaxCategories' );
+               }
 
                if( $wgUniversalEditButton ) {
                        if( isset( $wgArticle ) && $this->getTitle() && $this->getTitle()->quickUserCan( 'edit' )
index 9118db9..37392b6 100644 (file)
@@ -409,6 +409,8 @@ class Skin extends Linker {
                        'wgSeparatorTransformTable' => $compactSeparatorTransTable,
                        'wgDigitTransformTable' => $compactDigitTransTable,
                        'wgMainPageTitle' => $mainPage ? $mainPage->getPrefixedText() : null,
+                       'wgFormattedNamespaces' => $wgContLang->getFormattedNamespaces(),
+                       'wgNamespaceIds' => $wgContLang->getNamespaceIds(),
                );
                if ( $wgContLang->hasVariants() ) {
                        $vars['wgUserVariant'] = $wgContLang->getPreferredVariant();
index 1540b95..4929a30 100644 (file)
@@ -567,7 +567,9 @@ class Xml {
                        $s = 'null';
                } elseif ( is_int( $value ) ) {
                        $s = $value;
-               } elseif ( is_array( $value ) ) {
+               } elseif ( is_array( $value ) && // Make sure it's not associative.
+                                       array_keys($value) === range(0,count($value)-1)
+                               ) {
                        $s = '[';
                        foreach ( $value as $elt ) {
                                if ( $s != '[' ) {
@@ -576,7 +578,8 @@ class Xml {
                                $s .= self::encodeJsVar( $elt );
                        }
                        $s .= ']';
-               } elseif ( is_object( $value ) ) {
+               } elseif ( is_object( $value ) || is_array( $value ) ) {
+                       // Objects and associative arrays
                        $s = '{';
                        foreach ( (array)$value as $name => $elt ) {
                                if ( $s != '{' ) {
diff --git a/js2/ajaxcategories.js b/js2/ajaxcategories.js
new file mode 100644 (file)
index 0000000..8b204d7
--- /dev/null
@@ -0,0 +1,315 @@
+loadGM( {
+               "ajax-add-category":"[Add Category]",
+               "ajax-add-category-submit":"[Add]",
+               "ajax-confirm-prompt":"[Confirmation Text]",
+               "ajax-confirm-title":"[Confirmation Title]",
+               "ajax-confirm-save":"[Save]",
+               "ajax-add-category-summary":"[Add category $1]",
+               "ajax-remove-category-summary":"[Remove category $2]",
+               "ajax-confirm-actionsummary":"[Summary]",
+               "ajax-error-title":"Error",
+               "ajax-error-dismiss":"OK",
+               "ajax-remove-category-error":"[RemoveErr]"
+               } );
+
+var ajaxCategories = {
+
+       handleAddLink : function(e) {
+               e.preventDefault();
+               
+               // Make sure the suggestion plugin is loaded. Load everything else while we're at it
+               mvJsLoader.doLoad( ['$j.ui', '$j.ui.dialog', '$j.fn.suggestions'],
+                       function() {
+                               $j('#mw-addcategory-prompt').toggle();
+                               
+                               $j('#mw-addcategory-input').suggestions( {
+                                               'fetch':ajaxCategories.fetchSuggestions,
+                                               'cancel': function() {
+                                                               var req = ajaxCategories.request;
+                                                               if (req.abort)
+                                                                       req.abort()
+                                                       },
+                                       } );
+                                       
+                               $j('#mw-addcategory-input').suggestions();
+                       } );
+       },
+       
+       fetchSuggestions : function( query ) {
+               var that = this;
+               var request = $j.ajax( {
+                       url: wgScriptPath + '/api.php',
+                       data: {
+                               'action': 'query',
+                               'list': 'allpages',
+                               'apnamespace': 14,
+                               'apprefix': $j(this).val(),
+                               'format': 'json'
+                       },
+                       dataType: 'json',
+                       success: function( data ) {
+                               // Process data.query.allpages into an array of titles
+                               var pages = data.query.allpages;
+                               var titleArr = [];
+                               
+                               $j.each(pages, function(i, page) {
+                                       var title = page.title.split( ':', 2 )[1];
+                                       titleArr.push(title);
+                               } );
+                               
+                               $j(that).suggestions( 'suggestions', titleArr );
+                       }
+               });
+               
+               ajaxCategories.request = request;
+       },
+       
+       reloadCategoryList : function( response ) {
+               var holder = $j('<div/>');
+               
+               holder.load( window.location.href+' .catlinks', function() {
+                       $j('.catlinks').replaceWith( holder.find('.catlinks') );
+                       ajaxCategories.setupAJAXCategories();
+                       ajaxCategories.removeProgressIndicator( $j('.catlinks') );
+               });
+       },
+       
+       confirmEdit : function( page, fn, actionSummary, doneFn ) {
+               // Load jQuery UI
+               mvJsLoader.doLoad( ['$j.ui', '$j.ui.dialog', '$j.suggestions'], function() {
+                               // Produce a confirmation dialog
+                               
+                               var dialog = $j('<div/>');
+                               
+                               dialog.addClass('mw-ajax-confirm-dialog');
+                               dialog.attr( 'title', gM('ajax-confirm-title') );
+                               
+                               // Intro text.
+                               var confirmIntro = $j('<p/>');
+                               confirmIntro.text( gM('ajax-confirm-prompt') );
+                               dialog.append(confirmIntro);
+                               
+                               // Summary of the action to be taken
+                               var summaryHolder = $j('<p/>');
+                               var summaryLabel = $j('<strong/>');
+                               summaryLabel.text(gM('ajax-confirm-actionsummary')+" " );
+                               summaryHolder.text( actionSummary );
+                               summaryHolder.prepend( summaryLabel );
+                               dialog.append(summaryHolder);
+                               
+                               // Reason textbox.
+                               var reasonBox = $j('<input type="text" size="45" />');
+                               reasonBox.addClass('mw-ajax-confirm-reason');
+                               dialog.append(reasonBox);
+                               
+                               // Submit button
+                               var submitButton = $j('<input type="button"/>');
+                               submitButton.val( gM( 'ajax-confirm-save' ) );
+                               
+                               var submitFunction = function() {
+                                               ajaxCategories.addProgressIndicator( dialog );
+                                               ajaxCategories.doEdit( page, fn, reasonBox.val(),
+                                                               function() {
+                                                                       doneFn();
+                                                                       dialog.dialog('close');
+                                                                       ajaxCategories.removeProgressIndicator( dialog );
+                                                               }
+                                                       );
+                                       };
+                               
+                               var buttons = {};
+                               buttons[gM('ajax-confirm-save')] = submitFunction;
+                               var dialogOptions = { 
+                                                                               'AutoOpen' : true,
+                                                                               'buttons' : buttons,
+                                                                               'width' : 450,
+                                                                       };
+                               
+                               $j('#catlinks').prepend(dialog);
+                               dialog.dialog( dialogOptions );
+                       } );
+       },
+       
+       doEdit : function( page, fn, summary, doneFn ) {
+               // Get an edit token for the page.
+               var getTokenVars = {
+                                                       'action':'query',
+                                                       'prop':'info|revisions',
+                                                       'intoken':'edit',
+                                                       'titles':page,
+                                                       'rvprop':'content|timestamp',
+                                                       'format':'json',
+                                                       };
+               $j.get(wgScriptPath+'/api.php', getTokenVars, 
+                       function( reply ) {
+                               var infos = reply.query.pages;
+                               $j.each(infos, function(pageid, data) {
+                                       var token = data.edittoken;
+                                       var timestamp = data.revisions[0].timestamp;
+                                       var oldText = data.revisions[0]['*'];
+                                       
+                                       var newText = fn(oldText);
+                                       
+                                       if (newText === false) return;
+                                       
+                                       var postEditVars = {
+                                                       'action':'edit',
+                                                       'title':page,
+                                                       'text':newText,
+                                                       'summary':summary,
+                                                       'token':token,
+                                                       'basetimestamp':timestamp,
+                                                       'format':'json',
+                                                       };
+                                       
+                                       $j.post( wgScriptPath+'/api.php', postEditVars, doneFn, 'json' );
+                               } );
+                       }
+               , 'json' );
+       },
+       
+       addProgressIndicator : function( elem ) {
+               var indicator = $j('<div/>');
+               
+               indicator.addClass('mw-ajax-loader');
+               
+               elem.append( indicator );
+       },
+       
+       removeProgressIndicator : function( elem ) {
+               elem.find('.mw-ajax-loader').remove();
+       },
+       
+       handleCategoryAdd : function(e) {
+               // Grab category text
+               var category = $j('#mw-addcategory-input').val();
+               var appendText = "\n[["+wgFormattedNamespaces[14]+":"+category+"]]\n";
+               var summary = gM('ajax-add-category-summary', category);
+               
+               ajaxCategories.confirmEdit( wgPageName, function(oldText) { return oldText+appendText },
+                               summary, ajaxCategories.reloadCategoryList );
+       },
+       
+       handleDeleteLink : function(e) {
+               e.preventDefault();
+               
+               var category = $j(this).parent().find('a').text();
+               
+               // Build a regex that matches legal invocations of that category.
+               
+               // In theory I should escape the aliases, but there's no JS function for it
+               //  Shouldn't have any real impact, can't be exploited or anything, so we'll
+               //  leave it for now.
+               var categoryNSFragment = '';
+               $j.each(wgNamespaceIds, function( name, id ) {
+                       if (id == 14) {
+                               // Allow the first character to be any case
+                               var firstChar = name.charAt(0);
+                               firstChar = '['+firstChar.toUpperCase()+firstChar.toLowerCase()+']'
+                               categoryNSFragment += '|'+firstChar+name.substr(1);
+                       }
+               } );
+               categoryNSFragment = categoryNSFragment.substr(1) // Remove leading |
+               
+               
+               // Build the regex
+               var titleFragment = category;
+               
+               firstChar = category.charAt(0);
+               firstChar = '['+firstChar.toUpperCase()+firstChar.toLowerCase()+']';
+               titleFragment = firstChar+category.substr(1);
+               var categoryRegex = '\\[\\['+categoryNSFragment+':'+titleFragment+'(\\|[^\\]]*)?\\]\\]';
+               categoryRegex = new RegExp( categoryRegex, 'g' );
+               
+               var summary = gM('ajax-remove-category-summary', category);
+               
+               ajaxCategories.confirmEdit( wgPageName,
+                               function(oldText) {
+                                       var newText = oldText.replace(categoryRegex, '');
+                                       
+                                       if (newText == oldText) {
+                                               var error = gM('ajax-remove-category-error');
+                                               ajaxCategories.showError( error );
+                                               ajaxCategories.removeProgressIndicator( $j('.mw-ajax-confirm-dialog') );
+                                               $j('.mw-ajax-confirm-dialog').dialog('close');
+                                               return false;
+                                       }
+                                       
+                                       return newText;
+                               }, summary, ajaxCategories.reloadCategoryList );
+       },
+       
+       showError : function( str ) {
+               var dialog = $j('<div/>');
+               dialog.text(str);
+               
+               $j('#bodyContent').append(dialog);
+               
+               var buttons = {};
+               buttons[gM('ajax-error-dismiss')] = function(e) { dialog.dialog('close'); };
+               var dialogOptions = {
+                       'buttons' : buttons,
+                       'AutoOpen' : true,
+                       'title' : gM('ajax-error-title'),
+               };
+               
+               dialog.dialog(dialogOptions);
+       },
+       
+       setupAJAXCategories : function() {
+               var clElement = $j('.catlinks');
+               
+               // Unhide hidden category holders.
+               clElement.removeClass( 'catlinks-allhidden' );
+               
+               var addLink = $j('<a/>');
+               addLink.addClass( 'mw-ajax-addcategory' );
+               
+               // Create [Add Category] link
+               addLink.text( gM( 'ajax-add-category' ) );
+               addLink.attr('href', '#');
+               addLink.click( ajaxCategories.handleAddLink );
+               clElement.append(addLink);
+               
+               // Create add category prompt
+               var promptContainer = $j('<div id="mw-addcategory-prompt"/>');
+               var promptTextbox = $j('<input type="text" size="45" id="mw-addcategory-input"/>');
+               var addButton = $j('<input type="button" id="mw-addcategory-button"/>' );
+               addButton.val( gM('ajax-add-category-submit') );
+               
+               promptTextbox.keypress( ajaxCategories.handleCategoryInput );
+               addButton.click( ajaxCategories.handleCategoryAdd );
+               
+               promptContainer.append(promptTextbox);
+               promptContainer.append(addButton);
+               promptContainer.hide();
+               
+               // Create delete link for each category.
+               $j('.catlinks div span a').each( function(e) {
+                               // Create a remove link
+                               var deleteLink = $j('<a class="mw-remove-category" href="#"/>');        
+                               
+                               deleteLink.click(ajaxCategories.handleDeleteLink);
+                               
+                               $j(this).after(deleteLink);
+                       } );
+               
+               clElement.append(promptContainer);
+       },
+
+};
+
+js2AddOnloadHook( ajaxCategories.setupAJAXCategories );
+loadGM( {
+               "ajax-add-category":"[Add Category]",
+               "ajax-add-category-submit":"[Add]",
+               "ajax-confirm-prompt":"[Confirmation Text]",
+               "ajax-confirm-title":"[Confirmation Title]",
+               "ajax-confirm-save":"[Save]",
+               "ajax-add-category-summary":"[Add category $1]",
+               "ajax-remove-category-summary":"[Remove category $2]",
+               "ajax-confirm-actionsummary":"[Summary]",
+               "ajax-error-title":"Error",
+               "ajax-error-dismiss":"OK",
+               "ajax-remove-category-error":"[RemoveErr]"
+               } );
diff --git a/js2/mwEmbed/jquery/plugins/jquery.suggestions.js b/js2/mwEmbed/jquery/plugins/jquery.suggestions.js
new file mode 100644 (file)
index 0000000..489f52f
--- /dev/null
@@ -0,0 +1,459 @@
+/**
+ * This plugin provides a generic way to add suggestions to a text box
+ * Usage:
+ *
+ * Set options
+ *     $('#textbox').suggestions({ option1: value1, option2: value2 });
+ *     $('#textbox').suggestions( option, value );
+ * Get option:
+ *     value = $('#textbox').suggestions( option );
+ * Initialize:
+ *     $('#textbox').suggestions();
+ * 
+ * Available options:
+ * animationDuration: How long (in ms) the animated growing of the results box
+ *     should take (default: 200)
+ * cancelPending(): Function called when any pending asynchronous suggestions
+ *     fetches should be canceled (optional). Executed in the context of the
+ *     textbox
+ * delay: Number of ms to wait for the user to stop typing (default: 120)
+ * fetch(query): Callback that should fetch suggestions and set the suggestions
+ *     property (required). Executed in the context of the textbox
+ * maxGrowFactor: Maximum width of the suggestions box as a factor of the width
+ *     of the textbox (default: 2)
+ * maxRows: Maximum number of suggestion rows to show
+ * submitOnClick: If true, submit the form when a suggestion is clicked
+ *     (default: false)
+ * suggestions: Array of suggestions to display (default: [])
+ * 
+ */
+(function($) {
+$.fn.suggestions = function( param, param2 ) {
+       /**
+        * Handle special keypresses (arrow keys and escape)
+        * @param key Key code
+        */
+       function processKey( key ) {
+               switch ( key ) {
+                       case 40:
+                               // Arrow down
+                               if ( conf._data.div.is( ':visible' ) ) {
+                                       highlightResult( 'next', true );
+                               } else {
+                                       // Load suggestions right now
+                                       updateSuggestions( false );
+                               }
+                       break;
+                       case 38:
+                               // Arrow up
+                               if ( conf._data.div.is( ':visible' ) ) {
+                                       highlightResult( 'prev', true );
+                               }
+                       break;
+                       case 27:
+                               // Escape
+                               conf._data.div.hide();
+                               restoreText();
+                               cancelPendingSuggestions();
+                       break;
+                       default:
+                               updateSuggestions( true );
+               }
+       }
+       
+       /**
+        * Restore the text the user originally typed in the textbox,
+        * before it was overwritten by highlightResult(). This restores the
+        * value the currently displayed suggestions are based on, rather than
+        * the value just before highlightResult() overwrote it; the former
+        * is arguably slightly more sensible.
+        */
+       function restoreText() {
+               conf._data.textbox.val( conf._data.prevText );
+       }
+       
+       /**
+        * Ask the user-specified callback for new suggestions. Any previous
+        * delayed call to this function still pending will be canceled.
+        * If the value in the textbox hasn't changed since the last time
+        * suggestions were fetched, this function does nothing.
+        * @param delayed If true, delay this by the user-specified delay
+        */
+       function updateSuggestions( delayed ) {
+               // Cancel previous call
+               if ( conf._data.timerID != null )
+                       clearTimeout( conf._data.timerID );
+               if ( delayed )
+                       setTimeout( doUpdateSuggestions, conf.delay );
+               else
+                       doUpdateSuggestions();
+       }
+       
+       /**
+        * Delayed part of updateSuggestions()
+        * Don't call this, use updateSuggestions( false ) instead
+        */
+       function doUpdateSuggestions() {
+               if ( conf._data.textbox.val() == conf._data.prevText )
+                       // Value in textbox didn't change
+                       return;
+               
+               conf._data.prevText = conf._data.textbox.val();
+               conf.fetch.call ( conf._data.textbox,
+                       conf._data.textbox.val() );
+       }
+       
+       /**
+        * Called when the user changes the suggestions post-init.
+        * Typically happens asynchronously from conf.fetch()
+        */
+       function suggestionsChanged() {
+               conf._data.div.show();
+               updateSuggestionsTable();
+               fitContainer();
+               trimResultText();
+       }
+       
+       /**
+        * Cancel any delayed updateSuggestions() call and inform the user so
+        * they can cancel their result fetching if they use AJAX or something 
+        */
+       function cancelPendingSuggestions() {
+               if ( conf._data.timerID != null )
+                       clearTimeout( conf._data.timerID );
+               conf.cancelPending.call( this );
+       }
+       
+       /**
+        * Rebuild the suggestions table
+        */
+       function updateSuggestionsTable() {
+               // If there are no suggestions, hide the div
+               if ( conf.suggestions.length == 0 ) {
+                       conf._data.div.hide();
+                       return;
+               }
+               
+               var table = conf._data.div.children( 'table' );
+               table.empty();
+               for ( var i = 0; i < conf.suggestions.length; i++ ) {
+                       var td = $( '<td />' ) // FIXME: why use a span?
+                               .append( $( '<span />' ).text( conf.suggestions[i] ) );
+                               //.addClass( 'os-suggest-result' ); //FIXME: use descendant selector
+                       $( '<tr />' )
+                               .addClass( 'os-suggest-result' ) // FIXME: use descendant selector
+                               .attr( 'rel', i )
+                               .data( 'text', conf.suggestions[i] )
+                               .append( td )
+                               .appendTo( table );
+               }
+       }
+       
+       /**
+        * Make the container fit into the screen
+        */
+       function fitContainer() {
+               if ( conf._data.div.is( ':hidden' ) )
+                       return;
+               
+               // FIXME: Mysterious -20 from mwsuggest.js,
+               // presumably to make room for a scrollbar
+               var availableHeight = $( 'body' ).height() - (
+                       Math.round( conf._data.div.offset().top ) -
+                       $( document ).scrollTop() ) - 20;
+               var rowHeight = conf._data.div.find( 'tr' ).outerHeight();
+               var numRows = Math.floor( availableHeight / rowHeight );
+               
+               // Show at least 2 rows if there are multiple results
+               if ( numRows < 2 && conf.suggestions.length >= 2 )
+                       numRows = 2;
+               if ( numRows > conf.maxRows )
+                       numRows = conf.maxRows;
+               
+               var tableHeight = conf._data.div.find( 'table' ).outerHeight();
+               if ( numRows * rowHeight < tableHeight ) {
+                       // The container is too small
+                       conf._data.div.height( numRows * rowHeight );
+                       conf._data.visibleResults = numRows;
+               } else {
+                       // The container is possibly too large
+                       conf._data.div.height( tableHeight );
+                       conf._data.visibleResults = conf.suggestions.length;
+               }
+       }
+       
+       /**
+        * If there are results wider than the container, try to grow the
+        * container or trim them to end with "..."
+        */
+       function trimResultText() {
+               if ( conf._data.div.is( ':hidden' ) )
+                       return;
+               
+               // Try to grow the container so all results fit
+               // Can't use each() here because the inner function can read
+               // but not write maxWidth for some crazy reason
+               var maxWidth = 0;
+               var spans = conf._data.div.find( 'span' ).get();
+               for ( var i = 0; i < spans.length; i++ )
+                       if ( $(spans[i]).outerWidth() > maxWidth )
+                               maxWidth = $(spans[i]).outerWidth();
+               
+               // FIXME: Some mysterious fixing going on here
+               // FIXME: Left out Opera fix for now
+               // FIXME: This doesn't check that the container won't run off the screen
+               // FIXME: This should try growing to the left instead if no space on the right
+               var fix = 0;
+               if ( conf._data.visibleResults < conf.suggestions.length )
+                       fix = 20;
+               //else
+               //      fix = operaWidthFix();
+               if ( fix < 4 )
+                       // FIXME: Make 4px configurable?
+                       fix = 4; // Always pad at least 4px
+               maxWidth += fix;
+               
+               var textBoxWidth = conf._data.textbox.outerWidth();
+               var factor = maxWidth / textBoxWidth;
+               if ( factor > conf.maxGrowFactor ) 
+                       factor = conf.maxGrowFactor;
+               if ( factor < 1 )
+                       // Don't shrink the container to be smaller
+                       // than the textbox
+                       factor = 1;
+               var newWidth = Math.round( textBoxWidth * factor );
+               if ( newWidth != conf._data.div.outerWidth() )
+                       conf._data.div.animate( { width: newWidth },
+                               conf.animationDuration );
+               // FIXME: mwsuggest.js has this inside the if != block
+               // but I don't think that's right
+               newWidth -= fix;
+               
+               // If necessary, trim and add ...
+               conf._data.div.find( 'tr' ).each( function() {
+                       var span = $(this).find( 'span' );
+                       if ( span.outerWidth() > newWidth ) {
+                               var span = $(this).find( 'span' );
+                               span.text( span.text() + '...' );
+                               
+                               // While it's still too wide and the last
+                               // iteration shrunk it, remove the character
+                               // before '...'
+                               while ( span.outerWidth() > newWidth && span.text().length > 3 ) {
+                                       span.text( span.text().substring( 0,
+                                               span.text().length - 4 ) + '...' );
+                               }
+                               $(this).attr( 'title', $(this).data( 'text' ) );
+                       }
+               });
+       }
+       
+       /**
+        * Get a jQuery object for the currently highlighted row
+        */
+       function getHighlightedRow() {
+               return conf._data.div.find( '.os-suggest-result-hl' );
+       }
+       
+       /**
+        * Highlight a result in the results table
+        * @param result <tr> to highlight: jQuery object, or 'prev' or 'next'
+        * @param updateTextbox If true, put the suggestion in the textbox
+        */
+       function highlightResult( result, updateTextbox ) {
+               // TODO: Use our own class here
+               var selected = getHighlightedRow();
+               if ( !result.get || selected.get( 0 ) != result.get( 0 ) ) {
+                       if ( result == 'prev' ) {
+                               result = selected.prev();
+                       } else if ( result == 'next' ) {
+                               if ( selected.size() == 0 )
+                                       // No item selected, go to the first one
+                                       result = conf._data.div.find( 'tr:first' );
+                               else {
+                                       result = selected.next();
+                                       if ( result.size() == 0 )
+                                               // We were at the last item, stay there
+                                               result = selected;
+                               }
+                       }
+                       
+                       selected.removeClass( 'os-suggest-result-hl' );
+                       result.addClass( 'os-suggest-result-hl' );
+               }
+               
+               if ( updateTextbox ) {
+                       if ( result.size() == 0 )
+                               restoreText();
+                       else
+                               conf._data.textbox.val( result.data( 'text' ) );
+               }
+               
+               if ( result.size() > 0 && conf._data.visibleResults < conf.suggestions.length ) {
+                       // Not all suggestions are visible
+                       // Scroll if needed
+                       
+                       // height of a result row
+                       var rowHeight = result.outerHeight();
+                       // index of first visible element
+                       var first = conf._data.div.scrollTop() / rowHeight;  
+                       // index of last visible element
+                       var last = first + conf._data.visibleResults - 1;
+                       // index of element to scroll to
+                       var to = result.attr( 'rel' );
+                       
+                       if ( to < first )
+                               // Need to scroll up
+                               conf._data.div.scrollTop( to * rowHeight );
+                       else if ( result.attr( 'rel' ) > last )
+                               // Need to scroll down
+                               conf._data.div.scrollTop( ( to - conf._data.visibleResults + 1 ) * rowHeight );
+               }
+       }
+       
+       /**
+        * Initialize the widget
+        */
+       function init() {
+               if ( typeof conf != 'object' || typeof conf._data != 'undefined' )
+                       // Configuration not set or init already done
+                       return;
+               
+               // Set defaults
+               if ( typeof conf.animationDuration == 'undefined' )
+                       conf.animationDuration = 200;
+               if ( typeof conf.cancelPending != 'function' )
+                       conf.cancelPending = function() {};
+               if ( typeof conf.delay == 'undefined' )
+                       conf.delay = 250;
+               if ( typeof conf.maxGrowFactor == 'undefined' )
+                       conf.maxGrowFactor = 2;
+               if ( typeof conf.maxRows == 'undefined' )
+                       conf.maxRows = 7;
+               if ( typeof conf.submitOnClick == 'undefined' )
+                       conf.submitOnClick = false;
+               if ( typeof conf.suggestions != 'object' )
+                       conf.suggestions = [];
+               
+               conf._data = {};
+               conf._data.textbox = $(this);
+               conf._data.timerID = null; // ID of running timer
+               conf._data.prevText = null; // Text in textbox when suggestions were last fetched
+               conf._data.visibleResults = 0; // Number of results visible without scrolling
+               conf._data.mouseDownOn = $( [] ); // Suggestion the last mousedown event occured on
+       
+               // Create container div for suggestions
+               conf._data.div = $( '<div />' )
+                       .addClass( 'os-suggest' ) //TODO: use own CSS
+                       .css( {
+                               top: Math.round( $(this).offset().top ) + this.offsetHeight,
+                               left: Math.round( $(this).offset().left ),
+                               width: $(this).outerWidth()
+                       })
+                       .hide()
+                       .appendTo( $( 'body' ) );
+               
+               // Create results table
+               $( '<table />' )
+                       .addClass( 'os-suggest-results' ) // TODO: use descendant selector
+                       .width( $(this).outerWidth() ) // TODO: see if we need Opera width fix 
+                       .appendTo( conf._data.div );
+               
+               $(this)
+                       // Stop browser autocomplete from interfering
+                       .attr( 'autocomplete', 'off')
+                       .keydown( function( e ) {
+                               // Store key pressed to handle later
+                               conf._data.keypressed = (e.keyCode == undefined) ? e.which : e.keyCode;
+                               conf._data.keypressed_count = 0;
+                       })
+                       .keypress( function() {
+                               conf._data.keypressed_count++;
+                               processKey( conf._data.keypressed );
+                       })
+                       .keyup( function() {
+                               // Some browsers won't throw keypress() for
+                               // arrow keys. If we got a keydown and a keyup
+                               // without a keypress in between, solve that
+                               if (conf._data.keypressed_count == 0 )
+                                       processKey( conf._data.keypressed );
+                       })
+                       .blur( function() {
+                               // When losing focus because of a mousedown
+                               // on a suggestion, don't hide the suggestions 
+                               if ( conf._data.mouseDownOn.size() > 0 )
+                                       return;
+                               conf._data.div.hide();
+                               cancelPendingSuggestions();
+                       });
+               
+               conf._data.div
+                       .mouseover( function( e ) {
+                               var tr = $( e.target ).closest( '.os-suggest tr' );
+                               highlightResult( tr, false );
+                       })
+                       // Can't use click() because the container div is hidden
+                       // when the textbox loses focus. Instead, listen for a
+                       // mousedown followed by a mouseup on the same <tr>
+                       .mousedown( function( e ) {
+                               var tr = $( e.target ).closest( '.os-suggest tr' );
+                               conf._data.mouseDownOn = tr;
+                       })
+                       .mouseup( function( e ) {
+                               var tr = $( e.target ).closest( '.os-suggest tr' );
+                               var other = conf._data.mouseDownOn;
+                               conf._data.mouseDownOn = $( [] );
+                               if ( tr.get( 0 ) != other.get( 0 ) )
+                                       return;
+                                
+                               highlightResult( tr, true );
+                               conf._data.div.hide();
+                               conf._data.textbox.focus();
+                               if ( conf.submitOnClick )
+                                       conf._data.textbox.closest( 'form' )
+                                               .submit();
+                       });
+       }
+       
+       function getProperty( prop ) {
+               return ( param[0] == '_' ? undefined : conf[param] );
+       }
+       
+       function setProperty( prop, value ) {
+               if ( typeof conf == 'undefined' ) {
+                       $(this).data( 'suggestionsConfiguration', {} );
+                       conf = $(this).data( 'suggestionsConfiguration' );
+               }
+               if ( prop[0] != '_' )
+                       conf[prop] = value;
+               if ( prop == 'suggestions' && conf._data )
+                       // Setting suggestions post-init
+                       suggestionsChanged();
+       }
+       
+       
+       // Body of suggestions() starts here
+       var conf = $(this).data( 'suggestionsConfiguration' );
+       if ( typeof param == 'object' )
+               return this.each( function() {
+                       // Bulk-set properties
+                       for ( key in param ) {
+                               // Make sure that this in setProperty()
+                               // is set right
+                               setProperty.call( this, key, param[key] );
+                       }
+               });
+       else if ( typeof param == 'string' ) {
+               if ( typeof param2 != 'undefined' )
+                       return this.each( function() {
+                               setProperty( param, param2 );
+                       });
+               else
+                       return getProperty( param );
+       } else if ( typeof param != 'undefined' )
+               // Incorrect usage, ignore
+               return this;
+       
+       // No parameters given, initialize
+       return this.each( init );
+};})(jQuery);
index ab000c3..792c859 100644 (file)
@@ -191,6 +191,7 @@ lcPaths({
        "$j.secureEvalJSON"     : "jquery/plugins/jquery.secureEvalJSON.js",
        "$j.cookie"                     : "jquery/plugins/jquery.cookie.js",
        "$j.contextMenu"        : "jquery/plugins/jquery.contextMenu.js",
+       "$j.fn.suggestions"     : "jquery/plugins/jquery.suggestions.js",
 
        "$j.effects.blind"              : "jquery/jquery.ui/ui/effects.blind.js",
        "$j.effects.drop"               : "jquery/jquery.ui/ui/effects.drop.js",
index 160f9eb..50bfe04 100644 (file)
@@ -4194,4 +4194,18 @@ Enter the filename without the "{{ns:file}}:" prefix.',
 'htmlform-reset'               => 'Undo changes',
 'htmlform-selectorother-other' => 'Other',
 
+'ajax-add-category'                       => 'Add Category',
+'ajax-add-category-submit'        => 'Add',
+'ajax-confirm-title'              => 'Confirm Action',
+'ajax-confirm-prompt'             => 'Please confirm this action, and enter the reason for it in the
+box below. Once you are happy to submit it, click "Save". Note that repeatedly making false
+edits will result in your being blocked from Wikipedia.',
+'ajax-confirm-save'               => 'Save',
+'ajax-add-category-summary'    => 'Add category "$1"',
+'ajax-remove-category-summary' => 'Remove category "$1"',
+'ajax-confirm-actionsummary'   => 'Action to take:',
+'ajax-error-title'                        => 'Error',
+'ajax-error-dismiss'              => 'OK',
+'ajax-remove-category-error'   => 'It was not possible to remove this category. This usually
+occurs when the category has been added to the page in a template.',
 );
diff --git a/skins/common/images/add.png b/skins/common/images/add.png
new file mode 100755 (executable)
index 0000000..5b051f6
Binary files /dev/null and b/skins/common/images/add.png differ
diff --git a/skins/common/images/remove.png b/skins/common/images/remove.png
new file mode 100755 (executable)
index 0000000..0cbf7d7
Binary files /dev/null and b/skins/common/images/remove.png differ
index e59c592..bc00b41 100644 (file)
@@ -772,6 +772,30 @@ td.mw-enhanced-rc {
        font-family:monospace
 }
 
+#mw-addcategory-prompt {
+       display: inline;
+       margin-left: 1em;
+}
+#mw-addcategory-prompt input {
+       margin-left: 0.5em;
+       margin-right: 0.5em;
+}
+.mw-remove-category {
+       padding: 8px;
+       background-image: url(images/remove.png);
+       background-position: center center;
+       background-repeat: no-repeat;
+}
+.mw-ajax-addcategory {
+       padding-left: 20px;
+       background-image: url(images/add.png);
+       background-position: left center;
+       background-repeat: no-repeat;
+}
+
 .mw-ajax-loader {
        background-image: url(images/ajax-loader.gif);
        background-position: center center;